查看原文
其他

【第1706期】不只是同构应用(isomorphic 工程化你所忽略的细节)

LucasHC 前端早读课 2019-10-28

前言

今日早读文章由《React 状态管理与同构实战》作者@LucasHC授权分享

正文从这开始~~

不管是服务端渲染还是服务端渲染衍生出的同构应用,现在来看已经并不新鲜了,实现起来也并不困难。但是社区上相关文章质量良莠不齐,很多只是“纸上谈兵”,甚至有的开发者认为:同构应用不就是调用一个 renderToString(React 中)类似的 API 吗?

讲道理确实是这样的,但是讲道理你也许并没有真正在实战中领会同构应用的精髓。

同构应用能够实现的本质条件是虚拟 DOM,基于虚拟 DOM 我们可以生成真实的 DOM,并由浏览器渲染;也可以调用不同框架的不同 APIs,将虚拟 DOM 生成字符串,由服务端传输给客户端。

但是同构应用也不只是这么简单,它涉及到 NodeJS 层构建应用的方方面面。拿面试来说,同构应用的考察点不是“纸上谈兵”的理论,而是实际实施时的细节。今天我们就来聊一聊“同构应用工程中往往被忽略的细节”,需要读者提前了解服务端渲染和同构应用的概念。

相关知识点如下:

打包环境区分

第一个细节:我们知道同构应用实现了客户端代码和服务端代码的基本统一,我们只需要编写一种组件,就能生成适用于服务端和客户端的组件案例。可是你是否知道,服务端代码和客户端代码大多数情况下还是需要单独处理?比如:

路由代码差别:服务端需要根据请求路径,匹配页面组件;客户端需要通过浏览器中的地址,匹配页面组件。

来看一个例子,客户端代码:

  1. const App = () => {

  2. return (

  3. <Provider store={store}>

  4. <BrowserRouter>

  5. <div>

  6. <Route path='/' component={Home}>

  7. <Route path='/product' component={Product}>

  8. </div>

  9. </BrowserRouter>

  10. </Provider>

  11. )

  12. }

  13. ReactDom.render(<App/>, document.querySelector('#root'))

BrowserRouter 组件根据 window.location 以及 history API 实现页面切换,而服务端肯定是无法获取 window.location 的,服务端代码如下:

  1. const App = () => {

  2. return

  3. <Provider store={store}>

  4. <StaticRouter location={req.path} context={context}>

  5. <div>

  6. <Route path='/' component={Home}>

  7. </div>

  8. </StaticRouter>

  9. </Provider>

  10. }

  11. Return ReactDom.renderToString(<App/>)

需要使用 StaticRouter 组件,并将请求地址和上下文信息作为 location 和 context 这两个 props 传入 StaticRouter 中。

打包差别:服务端运行的代码如果需要依赖 Node 核心模块或者第三方模块,就不再需要把这些模块代码打包到最终代码中了。因为环境已经安装这些依赖,可以直接引用。这样一来,就需要我们在 webpack 中配置:target:node,并借助 webpack-node-externals 插件,解决第三方依赖打包的问题。

对于图片等静态资源,url-loader 会在服务端代码和客户端代码打包过程中分别被引用,因此会在资源目录中生成了重复的文件。当然后打包出来的因为重名,会覆盖前一次打包出来的结果,并不影响使用,但是整个构建过程并不优雅。

由于路由在服务端和客户端的差别,因此 webpack 配置文件的 entry 会不相同:

  1. {

  2. entry: './src/client/index.js',

  3. }


  4. {

  5. entry: './src/server/index.js',

  6. }

注水和脱水

第二个细节非常重要,涉及到数据的预获取。也是服务端渲染的真正意义。

什么叫做注水和脱水呢?这个和同构应用中数据的获取有关:在服务器端渲染时,首先服务端请求接口拿到数据,并处理准备好数据状态(如果使用 Redux,就是进行 store 的更新),为了减少客户端的请求,我们需要保留住这个状态。一般做法是在服务器端返回 HTML 字符串的时候,将数据 JSON.stringify 一并返回,这个过程,叫做脱水(dehydrate);在客户端,就不再需要进行数据的请求了,可以直接使用服务端下发下来的数据,这个过程叫注水(hydrate)。用代码来表示:

服务端:

  1. ctx.body = `

  2. <!DOCTYPE html>

  3. <html lang="en">

  4. <head>

  5. <meta charset="UTF-8">

  6. </head>

  7. <body>

  8. <script>

  9. window.context = {

  10. initialState: ${JSON.stringify(store.getState())}

  11. }

  12. </script>

  13. <div id="app">

  14. // ...

  15. </div>

  16. </body>

  17. </html>

  18. `

客户端:

  1. export const getClientStore = () => {

  2. const defaultState = JSON.parse(window.context.state)

  3. return createStore(reducer, defaultState, applyMiddleware(thunk))

  4. }

这一系列过程非常典型,但是也会有几个细节值得探讨:在服务端渲染时,服务端如何能够请求所有的数据请求 APIs,保障数据全部已经预先加载了呢?一般有两种方法:

react-router 的解决方案是配置路由 route-config,结合 matchRoutes,找到页面上相关组件所需的请求接口的方法并执行请求。这就要求开发者通过路由配置信息,显式地告知服务端请求内容。

我们首先配置路由:

  1. const routes = [

  2. {

  3. path: "/",

  4. component: Root,

  5. loadData: () => getSomeData()

  6. }

  7. // etc.

  8. ]


  9. import { routes } from "./routes"


  10. function App() {

  11. return (

  12. <Switch>

  13. {routes.map(route => (

  14. <Route {...route} />

  15. ))}

  16. </Switch>

  17. )

  18. }

在服务端代码中:

  1. import { matchPath } from "react-router-dom"


  2. const promises = []

  3. routes.some(route => {

  4. const match = matchPath(req.path, route)

  5. if (match) promises.push(route.loadData(match))

  6. return match

  7. })


  8. Promise.all(promises).then(data => {

  9. putTheDataSomewhereTheClientCanFindIt(data)

  10. })

另外一种思路类似 Next.js,我们需要在 React 组件上定义静态方法。

比如定义静态 loadData 方法,在服务端渲染时,我们可以遍历所有组件的 loadData,获取需要请求的接口。这样的方式借鉴了早期 React-apollo 的解决方案,我个人很喜欢这种设计。这里贴出我为 Facebook 团队著名的 react-graphQl-apollo 开源项目贡献的改动代码,其目的就是遍历组件,获取请求接口:

  1. function getPromisesFromTree({

  2. rootElement,

  3. rootContext = {},

  4. }: PromiseTreeArgument): PromiseTreeResult[] {

  5. const promises: PromiseTreeResult[] = [];


  6. walkTree(rootElement, rootContext, (_, instance, context, childContext) => {

  7. if (instance && hasFetchDataFunction(instance)) {

  8. const promise = instance.fetchData();

  9. if (isPromise<Object>(promise)) {

  10. promises.push({ promise, context: childContext || context, instance });

  11. return false;

  12. }

  13. }

  14. });


  15. return promises;

  16. }


  17. // Recurse a React Element tree, running visitor on each element.

  18. // If visitor returns `false`, don't call the element's render function

  19. // or recurse into its child elements.

  20. export function walkTree(

  21. element: React.ReactNode,

  22. context: Context,

  23. visitor: (

  24. element: React.ReactNode,

  25. instance: React.Component<any> | null,

  26. context: Context,

  27. childContext?: Context,

  28. ) => boolean | void,

  29. ) {

  30. if (Array.isArray(element)) {

  31. element.forEach(item => walkTree(item, context, visitor));

  32. return;

  33. }


  34. if (!element) {

  35. return;

  36. }


  37. // A stateless functional component or a class

  38. if (isReactElement(element)) {

  39. if (typeof element.type === 'function') {

  40. const Comp = element.type;

  41. const props = Object.assign({}, Comp.defaultProps, getProps(element));

  42. let childContext = context;

  43. let child;


  44. // Are we are a react class?

  45. if (isComponentClass(Comp)) {

  46. const instance = new Comp(props, context);

  47. // In case the user doesn't pass these to super in the constructor.

  48. // Note: `Component.props` are now readonly in `@types/react`, so

  49. // we're using `defineProperty` as a workaround (for now).

  50. Object.defineProperty(instance, 'props', {

  51. value: instance.props || props,

  52. });

  53. instance.context = instance.context || context;


  54. // Set the instance state to null (not undefined) if not set, to match React behaviour

  55. instance.state = instance.state || null;


  56. // Override setState to just change the state, not queue up an update

  57. // (we can't do the default React thing as we aren't mounted

  58. // "properly", however we don't need to re-render as we only support

  59. // setState in componentWillMount, which happens *before* render).

  60. instance.setState = newState => {

  61. if (typeof newState === 'function') {

  62. // React's TS type definitions don't contain context as a third parameter for

  63. // setState's updater function.

  64. // Remove this cast to `any` when that is fixed.

  65. newState = (newState as any)(instance.state, instance.props, instance.context);

  66. }

  67. instance.state = Object.assign({}, instance.state, newState);

  68. };


  69. if (Comp.getDerivedStateFromProps) {

  70. const result = Comp.getDerivedStateFromProps(instance.props, instance.state);

  71. if (result !== null) {

  72. instance.state = Object.assign({}, instance.state, result);

  73. }

  74. } else if (instance.UNSAFE_componentWillMount) {

  75. instance.UNSAFE_componentWillMount();

  76. } else if (instance.componentWillMount) {

  77. instance.componentWillMount();

  78. }


  79. if (providesChildContext(instance)) {

  80. childContext = Object.assign({}, context, instance.getChildContext());

  81. }


  82. if (visitor(element, instance, context, childContext) === false) {

  83. return;

  84. }


  85. child = instance.render();

  86. } else {

  87. // Just a stateless functional

  88. if (visitor(element, null, context) === false) {

  89. return;

  90. }


  91. child = Comp(props, context);

  92. }


  93. if (child) {

  94. if (Array.isArray(child)) {

  95. child.forEach(item => walkTree(item, childContext, visitor));

  96. } else {

  97. walkTree(child, childContext, visitor);

  98. }

  99. }

  100. } else if ((element.type as any)._context || (element.type as any).Consumer) {

  101. // A React context provider or consumer

  102. if (visitor(element, null, context) === false) {

  103. return;

  104. }


  105. let child;

  106. if ((element.type as any)._context) {

  107. // A provider - sets the context value before rendering children

  108. ((element.type as any)._context as any)._currentValue = element.props.value;

  109. child = element.props.children;

  110. } else {

  111. // A consumer

  112. child = element.props.children((element.type as any)._currentValue);

  113. }


  114. if (child) {

  115. if (Array.isArray(child)) {

  116. child.forEach(item => walkTree(item, context, visitor));

  117. } else {

  118. walkTree(child, context, visitor);

  119. }

  120. }

  121. } else {

  122. // A basic string or dom element, just get children

  123. if (visitor(element, null, context) === false) {

  124. return;

  125. }


  126. if (element.props && element.props.children) {

  127. React.Children.forEach(element.props.children, (child: any) => {

  128. if (child) {

  129. walkTree(child, context, visitor);

  130. }

  131. });

  132. }

  133. }

  134. } else if (typeof element === 'string' || typeof element === 'number') {

  135. // Just visit these, they are leaves so we don't keep traversing.

  136. visitor(element, null, context);

  137. }

  138. }

但是一个重要细节是:以 Next.js 为例,getInitialData 的方法必须要注册在根组件 App 当中。这样做的目的在于减少子孙组件的渲染。因为如果子孙组件也注入了 getInitialData 方法,那么如果不进行渲染,自然也就无法收集到该子孙组件 getInitialData 方法。

也就是说,基于 walkTree 的方案或者其他非配置化方案,我们都需要在服务端渲染两次。第一次的目的在于收集请求,第二次才是 renderToString 得到真正的渲染结果。

我们项目中的整个 isomorphic 过程可以简化为:

更多内容由于敏感性,不再展开。

令人期待的 React.suspense 可以解决 double rendering 的问题,但你知道原理是什么吗?后续我会写文章分析,欢迎关注~

注水和脱水,是同构应用最为核心和关键的细节点。

请求认证处理

上面讲到服务端预先请求数据,那么思考这样的场景:某个请求依赖 cookie 表明的用户信息,比如请求“我的学习计划列表”。这种情况下服务端请求是不同于客户端的,不会有浏览器添加 cookie 以及不含邮其他相关的 header 信息。这个请求在服务端发送时,一定不会拿到预期的结果。

为了解决这个问题,我们来看看 React-apollo 的解决方法:

  1. import { ApolloProvider } from 'react-apollo'

  2. import { ApolloClient } from 'apollo-client'

  3. import { createHttpLink } from 'apollo-link-http'

  4. import Express from 'express'

  5. import { StaticRouter } from 'react-router'

  6. import { InMemoryCache } from "apollo-cache-inmemory"


  7. import Layout from './routes/Layout'


  8. // Note you don't have to use any particular http server, but

  9. // we're using Express in this example

  10. const app = new Express();

  11. app.use((req, res) => {


  12. const client = new ApolloClient({

  13. ssrMode: true,

  14. // Remember that this is the interface the SSR server will use to connect to the

  15. // API server, so we need to ensure it isn't firewalled, etc

  16. link: createHttpLink({

  17. uri: 'http://localhost:3010',

  18. credentials: 'same-origin',

  19. headers: {

  20. cookie: req.header('Cookie'),

  21. },

  22. }),

  23. cache: new InMemoryCache(),

  24. });


  25. const context = {}


  26. // The client-side App will instead use <BrowserRouter>

  27. const App = (

  28. <ApolloProvider client={client}>

  29. <StaticRouter location={req.url} context={context}>

  30. <Layout />

  31. </StaticRouter>

  32. </ApolloProvider>

  33. );


  34. // rendering code (see below)

  35. })

这个做法也非常简单,原理是:服务端请求时需要保留客户端页面请求的信息,并在 API 请求时携带并透传这个信息。上述代码中,createHttpLink 方法调用时:

  1. headers: {

  2. cookie: req.header('Cookie'),

  3. },

这个配置项就是关键,它使得服务端的请求完整地还原了客户端信息,因此验证类接口也不再会有问题。

事实上,很多早期 React 完成服务端渲染的轮子比如 React-universally 都借鉴了 React-apollo 众多优秀思想,对这个话题感兴趣的读者可以抽空去了解 React-apollo。

样式问题处理

同构应用的样式处理容易被开发者所忽视,而一旦忽略,就会掉到坑里。比如,正常的服务端渲染只是返回了 HTML 字符串,样式需要浏览器加载完 CSS 后才会加上,这个样式添加的过程就会造成页面的闪动。

再比如,我们不能再使用 style-loader 了,因为这个 webpack loader 会在编译时将样式模块载入到 HTML header 中。但是在服务端渲染环境下,没有 window 对象,style-loader 进而会报错。一般我们换用 isomorphic-style-loader 来实现:

  1. {

  2. test: /\.css$/,

  3. use: [

  4. 'isomorphic-style-loader',

  5. 'css-loader',

  6. 'postcss-loader'

  7. ],

  8. }

同时 isomorphic-style-loader 也会解决页面样式闪动的问题。它的原理也不难理解:在服务器端输出 html 字符串的同时,也将样式插入到 html 字符串当中,将结果一同传送到客户端。

isomorphic-style-loader 具体做了什么呢,他是如何实现的?

我们知道对于 webpack 来说,所有的资源都是模块,webpack loader 在编译过程中可以将导入的 CSS 文件转换成对象,拿到样式信息。因此 isomorphic-style-loader 可以获取页面中所有组件样式。为了实现的更加通用化,isomorphic-style-loader 利用 context API,在渲染页面组件时获取所有 React 组件的样式信息,最终插入到 HTML 字符串中。

在服务端渲染时,我们需要加入这样的逻辑:

  1. import express from 'express'

  2. import React from 'react'

  3. import ReactDOM from 'react-dom'

  4. import StyleContext from 'isomorphic-style-loader/StyleContext'

  5. import App from './App.js'


  6. const server = express()

  7. const port = process.env.PORT || 3000


  8. // Server-side rendering of the React app

  9. server.get('*', (req, res, next) => {


  10. const css = new Set() // CSS for all rendered React components


  11. const insertCss = (...styles) => styles.forEach(style => css.add(style._getCss()))


  12. const body = ReactDOM.renderToString(

  13. <StyleContext.Provider value={{ insertCss }}>

  14. <App />

  15. </StyleContext.Provider>

  16. )

  17. const html = `<!doctype html>

  18. <html>

  19. <head>

  20. <script src="client.js" defer></script>

  21. <style>${[...css].join('')}</style>

  22. </head>

  23. <body>

  24. <div id="root">${body}</div>

  25. </body>

  26. </html>`

  27. res.status(200).send(html)

  28. })


  29. server.listen(port, () => {

  30. console.log(`Node.js app is running at http://localhost:${port}/`)

  31. })

我们定义了 css Set 类型来存储页面所有的样式,并定义了 insertCss 方法,该方法通过 context 传给每个 React 组件,这样每个组件在服务端渲染阶段就可以调用 insertCss 方法。该方法调用时,会将组件样式加入到 css Set 当中。

最后我们用 [...css].join('') 就可以获取页面的所有样式字符串。

强调一下,isomorphic-style-loader 的源码目前已经更新,采用了最新的 React hooks API,我推荐给 React 开发者阅读,相信一定收获很多!

meta tags 渲染

React 应用中,骨架往往类似:

  1. const App = () => {

  2. return (

  3. <div>

  4. <Component1 />

  5. <Component2 />

  6. </div>

  7. )

  8. }

  9. ReactDom.render(<App/>, document.querySelector('#root'))

App 组件嵌入到 document.querySelector('#root') 节点当中,一般是不包含 head 标签的。但是单页应用在切换路由时,可能也会需要动态修改 head 标签信息,比如 title 内容。也就是说:在单页面应用切换页面,不会经过服务端渲染,但是我们仍然需要更改 document 的 title 内容。

那么服务端如何渲染 meta tags head 标签就是一个常被忽略但是至关重要的话题,我们往往使用 React-helmet 库来解决问题。

Home 组件:

  1. import Helmet from "react-helmet";


  2. <div>

  3. <Helmet>

  4. <title>Home page</title>

  5. <meta name="description" content="Home page description" />

  6. </Helmet>

  7. <h1>Home component</h1>

Users 组件:

  1. <Helmet>

  2. <title>Users page</title>

  3. <meta name="description" content="Users page description" />

  4. </Helmet>

React-helmet 这个库会在 Home 组件和 Users 组件渲染时,检测到 Helmet,并自动执行副作用逻辑。执行副作用的过程:React-helmet 依赖了 react-side-effect 库,该库作者就是大名鼎鼎的 Dan abramov,也推荐给大家学习。

404 处理

当服务端渲染时,我们还需要留心对 404 的情况进行处理,有 layout.js 文件如下:

  1. <Switch>

  2. <Route path="/" exact component={Home} />

  3. <Route path="/users" exact component={Users} />

  4. </Switch>

当访问:/home 时,会得到一个空白页面,浏览器也没有得到 404 的状态码。为了处理这种情况,我们加入:

  1. <Switch>

  2. <Route path="/" exact component={Home} />

  3. <Route path="/users" exact component={Users} />

  4. <Route component={NotFound} />

  5. </Switch>

并创建 NotFound.js 文件:

  1. import React from 'react'


  2. export default function NotFound({ staticContext }) {

  3. if (staticContext) {

  4. staticContext.notFound = true

  5. }

  6. return (

  7. <div>Not found</div>

  8. )

  9. }

注意,在访问一个不存在的地址时,我们要返回 404 状态码。一般 React router 类库已经帮我们进行了较好的封装,Static Router 会注入一个 context prop,并将 context.notFound 赋值为 true,在 server/index.js 加入:

  1. const context = {}

  2. const html = renderer(data, req.path, context);

  3. if (context.notFound) {

  4. res.status(404)

  5. }

  6. res.send(html)

即可。这一系列处理过程没有什么难点,但是这种处理意识,还是需要具备的。

安全问题

安全问题非常关键,尤其是涉及到服务端渲染,开发者要格外小心。这里提出一个点:我们前面提到了注水和脱水过程,其中的代码:

  1. ctx.body = `

  2. <!DOCTYPE html>

  3. <html lang="en">

  4. <head>

  5. <meta charset="UTF-8">

  6. </head>

  7. <body>

  8. <script>

  9. window.context = {

  10. initialState: ${JSON.stringify(store.getState())}

  11. }

  12. </script>

  13. <div id="app">

  14. // ...

  15. </div>

  16. </body>

  17. </html>

  18. `

非常容易遭受 XSS 攻击,JSON.stringify 可能会造成 script 注入。因此,我们需要严格清洗 JSON 字符串中的 HTML 标签和其他危险的字符。我习惯使用 serialize-javascript 库进行处理,这也是同构应用中最容易被忽视的细节。

另一个规避这种 XSS 风险的做法是:将数据传递个页面中一个隐藏的 textarea 的 value 中,textarea 的 value 自然就不怕 XSS 风险了。

这里给大家留一个思考题,React dangerouslySetInnerHTML API 也有类似风险,React 是怎么处理这个安全隐患的呢?

性能优化

我们将数据请求移到了服务端,但是依然要格外重视性能优化。目前针对于此,业界普遍做法包括以下几点。

  • 使用缓存:服务端优化一个最重要的手段就是缓存,不同于传统服务端缓存措施,我们甚至可以实现组件级缓存,业界 walmartlabs 在这方面的实践非常多,且收获了较大的性能提升。感兴趣的读者可以找到相关技术信息。

  • 采用 HSF 代替 HTTP,HSF 是 High-Speed Service Framework 的缩写,译为分布式的远程服务调用框架,对外提供服务上,HSF 性能远超过 HTTP。

  • 对于服务端压力过大的场景,动态切换为客户端渲染。

  • NodeJS 升级。

  • React 升级。

如图所示,React 16 在服务端渲染上的性能对比提升:

Beyond isomorphic

短短篇幅其实仍然无法说清楚同构应用的方方面面,如何优雅地设计一个 isomorphic 应用,将是开发者设计功力的体现。

在普通的 renderToString 调用之上,更“强大”、更“牛”的设计,比如我们需要关心以下问题:

  • 如何在服务端获取数据,包含获取深层组件跨层级的数据和携带鉴权信息的数据

  • 服务端渲染和客户端渲染的一致性

  • SPA 服务端渲染的一致性问题

  • 同构项目中,JS 和 CSS 内联和外联设计

  • 真正意义的流式渲染(区分假 renderToNodeStream 和 FaceBook 的 BigPipe)

  • Node 端请求的 timeout 时间设计,结合客户端动态“接力”渲染,服务端先返回带有 script 标签的(带有空数据指明信息)的 html 内容

最后一点我稍微提一下,我设计的理想同构应用的轮子启动时,获取一个 timeout 参数。服务端渲染真正在于服务端请求数据。在实际应用中比如,当前应用需要在服务端请求 6 组 RPC,在请求过程中超时(这个 timeout 由业务方设置),只拉取了 4 个接口,注水 4 组数据源。为了缩短 TTFB 的时间,服务端优先返回,剩下的未请求到的 2 个接口数据通过 script 标签注入页面,并进行返回,这样客户端超时前即可渲染页面。

开源的 react-server.io 也实现了类似功能,同时它通过指令化的组件,来做到服务端渲染时,数据的顺序可控性:

  1. getElements() {

  2. return <RootContainer>

  3. <RootElement when={headerPromise}>

  4. <Header />

  5. </RootElement>

  6. <RootContainer listen={bodyEmitter}>

  7. <MainContent />

  8. <RootElement when={sidebarPromise}>

  9. <Sidebar />

  10. </RootElement>

  11. </RootContainer>

  12. <TheFold />

  13. <Footer />

  14. </RootContainer>

  15. }

注意 RootElement 的 when props,以及 RootContainer 的 listen props,顾名思义,这些都实现渐进式渲染和服务端控制。

与此相关的其他概念以及上述技术细节的实现,由于篇幅原因,这里不再展开,未来我讲针对更高阶的同构应用设计产出更多文章。

最后,服务端渲染和目前革命性趋势 serverless 的结合也很值得期待,前一段在和狼叔聊天时得知阿里在积极尝试同构应用在 serverless 环境下的架构设计,我个人未来长期看好,也会在这个主题上分享更多内容。总结

本讲没有“手把手”教你实现服务端渲染的同构应用,因为这些知识并不困难,社区上资料也很多。我们从更高的角度出发,剖析同构应用中那些关键的细节点和疑难问题的解决方案,这些经验来源于真刀真枪的线上案例,如果读者没有开发过同构应用,也能从中全方位地了解关键信息,一旦掌握了这些细节,同构应用的实现就会更稳、更可靠。

同构应用其实远比理论复杂,绝对不是几个 APIs 和几台服务器就能完成的,希望大家多思考、多动手,一定会更有体会。

另外,同构应用各种细节也不止于此,坑也不止于此,还有更多 NodeJS 层面的设计也没有设计,欢迎大家和我讨论,保持联系,我也会贡献更多内容和资源。

关于本文 作者:@LucasHC 原文:https://zhuanlan.zhihu.com/p/79203739

为你推荐


【第1150期】CSS工程化演进


【第1560期】前端同构渲染的思考与实践

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存